Reader 是我們 Android library 裡面最外層的 API ,要測試它要先考慮它有跟那些元件作互動,以下列出了它有互動的元件:
ktRssReaderConfig
RssCache
XmlFetcher
Parser
這些互動的元件就是我們要做 mocking 的目標。
typealias Config = KtRssReaderConfig.() -> Unit
object Reader {
val logTag: String = this::class.java.simpleName
@Throws(Exception::class)
inline fun <reified T> read(
url: String,
customParser: ((String) -> T?) = { null },
config: Config = {},
): T {
// 略
}
@Throws(Exception::class)
suspend inline fun <reified T> coRead(
url: String,
crossinline customParser: ((String) -> T?) = { null },
crossinline config: Config = {}
) = suspendCoroutine<T> {
// 略
}
inline fun <reified T> flowRead(
url: String,
crossinline customParser: ((String) -> T?) = { null },
crossinline config: Config = {}
) = flow<T> { emit(read(url = url, customParser = customParser, config = config)) }
fun clearCache() {
// 略
}
}
(完整程式碼在這裡)
這是 Reader 的 API ,我們可以看到它其實是提供了不同的讀取 function: read
、 coRead
、 flowRead
,他們的差別只在於說是用哪種非同步的方式去讀取,內部的流程是一致,所以我們不用特別寫三組重複的 function 都測一樣的東西,把它們不同的部分包成 lambda,讓測試者決定要做甚麼事情,這其實就是有點像是註冊一個 callback 。我們可以來準備一下共用的部分程式碼:
abstract class ReaderTestBase {
protected val fakeUrl = "fakeUrl"
protected val fakeType = Const.RSS_STANDARD
private val fakeXmlContent = "fakeXmlContent"
@RelaxedMockK
protected lateinit var mockRssCache: DatabaseRssCache<RssStandardChannel>
@RelaxedMockK
protected lateinit var mockFetcher: XmlFetcher
@RelaxedMockK
protected lateinit var mockException: Exception
@Before
fun setup() {
MockKAnnotations.init(this)
mockkObject(ThreadUtils)
mockkObject(KtRssProvider)
}
@After
fun tearDown() {
clearAllMocks()
}
protected fun mockGetRemoteChannelSuccessfully(block: (RssStandardChannel) -> Unit) {
// 略
}
protected fun mockGetRemoteChannelFailed(block: () -> Unit) {
// 略
}
protected fun mockGetCacheChannelSuccessfully(block: (RssStandardChannel) -> Unit, ) {
// 略
}
protected fun mockGetCacheChannelFailed(block: (RssStandardChannel) -> Unit) {
// 略
}
protected fun mockFlushCache(block: (RssStandardChannel) -> Unit) {
// 略
}
protected fun mockFetchDataSuccessfullyButSaveCacheFailed(block: (RssStandardChannel) -> Unit) {
// 略
}
}
我們可以看到在 class 一開始我們先宣告一系列的 @RelaxedMockK
這些是我們在過程中會互動的元件,接著,在 setup 的部分, MockKAnnotations.init(this)
先初始化我們用到的 MockK annotation ,如果你是直接寫 mockk<T>()
而沒有用到其他的 annotation 的話,應該是不用初始化。我們還有準備 mock 兩個 object , ThreadUtil
和 KtRssProvider
。 Mock ThreadUtil
是因為我們在 read
function 有檢查呼叫的當下是否不是在 main thread ,而 KtRssProvider
是我們設計來注入一些會用到的互動元件,有一點點像是 dependency injection 的味道,但是是土炮版本,畢竟我們只有在這邊有用到,不會直接引入一整個 DI library 。 KtRssProvider
注入的東西有 database、fetcher 、 parser 、 cache,待會我們就可以直接 mock KtRssProvider
對它指定回傳的東西,是不是很方便?在每項測試結束的時候,我們要把上一個測項的 mock 物件清理乾淨,所以在 @After
的地方要記得˙呼叫 clearAllMocks()
,確保下個測項正常運作。在這個類別的尾端,我們可以看到有幾個 mockXXX 的 function ,裡面都帶有 block ,這個就是剛剛提到抽取出來不同流程的 lambda ,讓外部使用者決定要測的東西。我們挑一個 function 了解一下裡面的作法:
protected fun mockGetRemoteChannelSuccessfully(block: (RssStandardChannel) -> Unit) {
val expected = mockkRelaxed<RssStandardChannelData>()
every { ThreadUtils.isMainThread() } returns false
every { KtRssProvider.provideRssCache<RssStandardChannel>() } returns mockRssCache
every { mockRssCache.readCache(fakeUrl, fakeType, any()) } returns null
every { KtRssProvider.provideXmlFetcher() } returns mockFetcher
every { mockFetcher.fetch(url = fakeUrl, charset = any()) } returns fakeXmlContent
mockkConstructor(AndroidRssStandardParser::class)
every { anyConstructed<AndroidRssStandardParser>().parse(fakeXmlContent) } returns expected
every { ThreadUtils.runOnNewThread(any(), any()) } answers {
mockRssCache.saveCache(fakeUrl, expected)
}
block(expected)
}
這個 function 是準備正常讀取流程會用到的 mock 物件與行為,為了接下來的流程準備,而準備完畢後就是呼叫 block
內的 lambda 來驗證測試結果,所以今天不管這個流程是在一般的 read
被呼叫或是在 coroutine 裡面被呼叫,都不用再寫第二遍。另外,為什麼 reader 在讀取和測試的時候都不會遇到型別錯誤的問題?因為我們在實作 parser 和 reader 的時候都是用泛型的方式去實作,所以保留了很大一部分的彈性,就算是用 annotation processor 產生的 parser 程式碼也可以透過 reader 外部 API 塞進去。
class ReadTest : ReaderTestBase() {
// 略
@Test
fun `Get remote channel successfully`() = mockGetRemoteChannelSuccessfully { mockItem ->
val actual = Reader.read<RssStandardChannel>(fakeUrl) {
useCache = false
}
never {
mockRssCache.readCache(fakeUrl, fakeType, any())
mockRssCache.saveCache(fakeUrl, mockItem)
}
actual shouldBe mockItem
}
// 略
}
class FlowReadTest : ReaderTestBase() {
// 略
@Test
fun `Get remote channel successfully`() = mockGetRemoteChannelSuccessfully { mockItem ->
runBlocking {
Reader.flowRead<RssStandardChannel>(fakeUrl) {
useCache = false
}.test {
mockItem shouldBe expectItem()
expectComplete()
}
}
// 略
}
class CoroutineReadTest : ReaderTestBase() {
// 略
@Test
fun `Get remote channel successfully`() = mockGetRemoteChannelSuccessfully { mockItem ->
runBlocking {
val actual = Reader.coRead<RssStandardChannel>(fakeUrl) {
useCache = false
}
actual shouldBe mockItem
}
}
// 略
}
最後,我們就可以使用剛剛寫好的 base class 去進行不同種的測試,第一種是在正常地呼叫 read
function ,第二種則是呼叫 flowRead
,最後一種是在 coroutine 上呼叫。
使用 MockK 來寫測試是真的很方便,只要專注在測試本身上面就好,不用去管太多 mock 物件怎麼生成,當然這些範例只是我們在 library 中測試的一小部分,如果想要更多測試範例的朋友可以直接去看我們的測試程式碼,除了 :processorTest
裡面的測試,各 module 裡面也有許多的 android test 和 local unit test 可以參考。